/*!
 * Jinja Templating for JavaScript v0.1.8
 * https://github.com/sstur/jinja-js
 *
 * This is a slimmed-down Jinja2 implementation [http://jinja.pocoo.org/]
 *
 * In the interest of simplicity, it deviates from Jinja2 as follows:
 * - Line statements, cycle, super, macro tags and block nesting are not implemented
 * - auto escapes html by default (the filter is "html" not "e")
 * - Only "html" and "safe" filters are built in
 * - Filters are not valid in expressions; `foo|length > 1` is not valid
 * - Expression Tests (`if num is odd`) not implemented (`is` translates to `==` and `isnot` to `!=`)
 *
 * Notes:
 * - if property is not found, but method '_get' exists, it will be called with the property name (and cached)
 * - `{% for n in obj %}` iterates the object's keys; get the value with `{% for n in obj %}{{ obj[n] }}{% endfor %}`
 * - subscript notation `a[0]` takes literals or simple variables but not `a[item.key]`
 * - `.2` is not a valid number literal; use `0.2`
 *
 */
/*global require, exports, module, define */

const STRINGS = /'(\\.|[^'])*'|"(\\.|[^"'"])*"/g;
const IDENTS_AND_NUMS = /([$_a-z][$\w]*)|([+-]?\d+(\.\d+)?)/g;
const NUMBER = /^[+-]?\d+(\.\d+)?$/;
//non-primitive literals (array and object literals)
const NON_PRIMITIVES = /\[[@#~](,[@#~])*\]|\[\]|\{([@i]:[@#~])(,[@i]:[@#~])*\}|\{\}/g;
//bare identifiers such as variables and in object literals: {foo: 'value'}
const IDENTIFIERS = /[$_a-z][$\w]*/gi;
const VARIABLES = /i(\.i|\[[@#i]\])*/g;
const ACCESSOR = /(\.i|\[[@#i]\])/g;
const OPERATORS = /(===?|!==?|>=?|<=?|&&|\|\||[+\-\*\/%])/g;
//extended (english) operators
const EOPS = /(^|[^$\w])(and|or|not|is|isnot)([^$\w]|$)/g;
const LEADING_SPACE = /^\s+/;
const TRAILING_SPACE = /\s+$/;

const START_TOKEN = /\{\{\{|\{\{|\{%|\{#/;
const TAGS = {
  '{{{': /^('(\\.|[^'])*'|"(\\.|[^"'"])*"|.)+?\}\}\}/,
  '{{': /^('(\\.|[^'])*'|"(\\.|[^"'"])*"|.)+?\}\}/,
  '{%': /^('(\\.|[^'])*'|"(\\.|[^"'"])*"|.)+?%\}/,
  '{#': /^('(\\.|[^'])*'|"(\\.|[^"'"])*"|.)+?#\}/,
};

const delimeters = {
  '{%': 'directive',
  '{{': 'output',
  '{#': 'comment',
};

const operators = {
  and: '&&',
  or: '||',
  not: '!',
  is: '==',
  isnot: '!=',
};

const constants = {
  true: true,
  false: false,
  null: null,
};

const trimLeft = (str: string) => {
  return str.replace(LEADING_SPACE, '');
};

const trimRight = (str: string) => {
  return str.replace(TRAILING_SPACE, '');
};

const matchAll = (str: string, reg: RegExp, fn: Function) => {
  //copy as global
  reg = new RegExp(reg.source, 'g' + (reg.ignoreCase ? 'i' : '') + (reg.multiline ? 'm' : ''));
  var match;
  while ((match = reg.exec(str))) {
    var result = fn(match[0], match.index, str);
    if (typeof result == 'number') {
      reg.lastIndex = result;
    }
  }
};

class Parser {
  nest: any[];
  compiled: any[];
  childBlocks: number;
  parentBlocks: number;
  isSilent: boolean;

  constructor() {
    this.nest = [];
    this.compiled = [];
    this.childBlocks = 0;
    this.parentBlocks = 0;
    this.isSilent = false;
  }

  push(line) {
    if (!this.isSilent) {
      this.compiled.push(line);
    }
  }
  parse(src) {
    this.tokenize(src);
    return this.compiled;
  }
  tokenize(src) {
    var lastEnd = 0,
      parser = this,
      trimLeading = false;
    matchAll(src, START_TOKEN, function (open, index, src) {
      //here we match the rest of the src against a regex for this tag
      var match = src.slice(index + open.length).match(TAGS[open]);
      match = match ? match[0] : '';
      //here we sub out strings so we don't get false matches
      var simplified = match.replace(STRINGS, '@');
      //if we don't have a close tag or there is a nested open tag
      if (!match || ~simplified.indexOf(open)) {
        return index + 1;
      }
      var inner = match.slice(0, 0 - open.length);
      //check for white-space collapse syntax
      var wsCollapseLeft, wsCollapseRight;
      if (inner.charAt(0) === '-') wsCollapseLeft = true;
      if (inner.slice(-1) === '-') wsCollapseRight = true;
      inner = inner.replace(/^-|-$/g, '').trim();
      //if we're in raw mode and we are not looking at an "endraw" tag, move along
      if (parser.rawMode && open + inner !== '{%endraw') {
        return index + 1;
      }
      var text = src.slice(lastEnd, index);
      lastEnd = index + open.length + match.length;
      if (trimLeading) text = trimLeft(text);
      if (wsCollapseLeft) text = trimRight(text);
      if (wsCollapseRight) trimLeading = true;
      if (open === '{{{') {
        //liquid-style: make {{{x}}} => {{x|safe}}
        open = '{{';
        inner += '|safe';
      }
      parser.textHandler(text);
      parser.tokenHandler(open, inner);
    });
    var text = src.slice(lastEnd);
    if (trimLeading) text = trimLeft(text);
    this.textHandler(text);
  }
  textHandler(text) {
    this.push('write(' + JSON.stringify(text) + ');');
  }
  tokenHandler(open, inner) {
    var type = delimeters[open];
    if (type === 'directive') {
      this.compileTag(inner);
    } else if (type === 'output') {
      var extracted = this.extractEnt(inner, STRINGS, '@');
      //replace || operators with ~
      extracted.src = extracted.src.replace(/\|\|/g, '~').split('|');
      //put back || operators
      extracted.src = extracted.src.map(function (part) {
        return part.split('~').join('||');
      });
      var parts = this.injectEnt(extracted, '@');
      if (parts.length > 1) {
        var filters = parts.slice(1).map(this.parseFilter.bind(this));
        this.push('filter(' + this.parseExpr(parts[0]) + ',' + filters.join(',') + ');');
      } else {
        this.push('filter(' + this.parseExpr(parts[0]) + ');');
      }
    }
  }
  compileTag(str) {
    var directive = str.split(' ')[0];
    var handler = tagHandlers[directive];
    if (!handler) {
      throw new Error('Invalid tag: ' + str);
    }
    handler.call(this, str.slice(directive.length).trim());
  }
  parseFilter(src) {
    src = src.trim();
    var match = src.match(/[:(]/);
    var i = match ? match.index : -1;
    if (i < 0) return JSON.stringify([src]);
    var name = src.slice(0, i);
    var args = src.charAt(i) === ':' ? src.slice(i + 1) : src.slice(i + 1, -1);
    args = this.parseExpr(args, { terms: true });
    return '[' + JSON.stringify(name) + ',' + args + ']';
  }
  extractEnt(src, regex, placeholder) {
    var subs: any[] = [],
      isFunc = typeof placeholder == 'function';
    src = src.replace(regex, function (str) {
      var replacement = isFunc ? placeholder(str) : placeholder;
      if (replacement) {
        subs.push(str);
        return replacement;
      }
      return str;
    });
    return { src: src, subs: subs };
  }
  injectEnt(extracted, placeholder) {
    var src = extracted.src,
      subs = extracted.subs,
      isArr = Array.isArray(src);
    var arr = isArr ? src : [src];
    var re = new RegExp('[' + placeholder + ']', 'g'),
      i = 0;
    arr.forEach(function (src, index) {
      arr[index] = src.replace(re, function () {
        return subs[i++];
      });
    });
    return isArr ? arr : arr[0];
  }
  //replace complex literals without mistaking subscript notation with array literals
  replaceComplex(s) {
    var parsed = this.extractEnt(s, /i(\.i|\[[@#i]\])+/g, 'v');
    parsed.src = parsed.src.replace(NON_PRIMITIVES, '~');
    return this.injectEnt(parsed, 'v');
  }
  //parse expression containing literals (including objects/arrays) and variables (including dot and subscript notation)
  //valid expressions: `a + 1 > b.c or c == null`, `a and b[1] != c`, `(a < b) or (c < d and e)`, 'a || [1]`
  parseExpr(src, opts) {
    opts = opts || {};
    //extract string literals -> @
    var parsed1 = this.extractEnt(src, STRINGS, '@');
    //note: this will catch {not: 1} and a.is; could we replace temporarily and then check adjacent chars?
    parsed1.src = parsed1.src.replace(EOPS, function (s, before, op, after) {
      return op in operators ? before + operators[op] + after : s;
    });
    //sub out non-string literals (numbers/true/false/null) -> #
    // the distinction is necessary because @ can be object identifiers, # cannot
    var parsed2 = this.extractEnt(parsed1.src, IDENTS_AND_NUMS, function (s) {
      return s in constants || NUMBER.test(s) ? '#' : null;
    });
    //sub out object/variable identifiers -> i
    var parsed3 = this.extractEnt(parsed2.src, IDENTIFIERS, 'i');
    //remove white-space
    parsed3.src = parsed3.src.replace(/\s+/g, '');

    //the rest of this is simply to boil the expression down and check validity
    var simplified = parsed3.src;
    //sub out complex literals (objects/arrays) -> ~
    // the distinction is necessary because @ and # can be subscripts but ~ cannot
    while (simplified !== (simplified = this.replaceComplex(simplified)));
    //now @ represents strings, # represents other primitives and ~ represents non-primitives
    //replace complex variables (those with dot/subscript accessors) -> v
    while (simplified !== (simplified = simplified.replace(/i(\.i|\[[@#i]\])+/, 'v')));
    //empty subscript or complex variables in subscript, are not permitted
    simplified = simplified.replace(/[iv]\[v?\]/g, 'x');
    //sub in "i" for @ and # and ~ and v (now "i" represents all literals, variables and identifiers)
    simplified = simplified.replace(/[@#~v]/g, 'i');
    //sub out operators
    simplified = simplified.replace(OPERATORS, '%');
    //allow 'not' unary operator
    simplified = simplified.replace(/!+[i]/g, 'i');
    var terms = opts.terms ? simplified.split(',') : [simplified];
    terms.forEach(function (term) {
      //simplify logical grouping
      while (term !== (term = term.replace(/\(i(%i)*\)/g, 'i')));
      if (!term.match(/^i(%i)*/)) {
        throw new Error('Invalid expression: ' + src + ' ' + term);
      }
    });
    parsed3.src = parsed3.src.replace(VARIABLES, this.parseVar.bind(this));
    parsed2.src = this.injectEnt(parsed3, 'i');
    parsed1.src = this.injectEnt(parsed2, '#');
    return this.injectEnt(parsed1, '@');
  }
  parseVar(src) {
    var args = Array.prototype.slice.call(arguments);
    var str = args.pop(),
      index = args.pop();
    //quote bare object identifiers (might be a reserved word like {while: 1})
    if (src === 'i' && str.charAt(index + 1) === ':') {
      return '"i"';
    }
    var parts = ['"i"'];
    src.replace(ACCESSOR, function (part) {
      if (part === '.i') {
        parts.push('"i"');
      } else if (part === '[i]') {
        parts.push('get("i")');
      } else {
        parts.push(part.slice(1, -1));
      }
    });
    return 'get(' + parts.join(',') + ')';
  }
  escName(str) {
    return str.replace(/\W/g, function (s) {
      return '$' + s.charCodeAt(0).toString(16);
    });
  }
  parseQuoted(str) {
    if (str.charAt(0) === "'") {
      str = str.slice(1, -1).replace(/\\.|"/, function (s) {
        if (s === "\\'") return "'";
        return s.charAt(0) === '\\' ? s : '\\' + s;
      });
      str = '"' + str + '"';
    }
    //todo: try/catch or deal with invalid characters (linebreaks, control characters)
    return JSON.parse(str);
  }
}

//the context 'this' inside tagHandlers is the parser instance
const tagHandlers = {
  if: function (expr) {
    this.push('if (' + this.parseExpr(expr) + ') {');
    this.nest.unshift('if');
  },
  else: function () {
    if (this.nest[0] === 'for') {
      this.push('}, function() {');
    } else {
      this.push('} else {');
    }
  },
  elseif: function (expr) {
    this.push('} else if (' + this.parseExpr(expr) + ') {');
  },
  endif: function () {
    this.nest.shift();
    this.push('}');
  },
  for: function (str) {
    var i = str.indexOf(' in ');
    var name = str.slice(0, i).trim();
    var expr = str.slice(i + 4).trim();
    this.push('each(' + this.parseExpr(expr) + ',' + JSON.stringify(name) + ',function() {');
    this.nest.unshift('for');
  },
  endfor: function () {
    this.nest.shift();
    this.push('});');
  },
  raw: function () {
    this.rawMode = true;
  },
  endraw: function () {
    this.rawMode = false;
  },
  set: function (stmt) {
    var i = stmt.indexOf('=');
    var name = stmt.slice(0, i).trim();
    var expr = stmt.slice(i + 1).trim();
    this.push('set(' + JSON.stringify(name) + ',' + this.parseExpr(expr) + ');');
  },
  block: function (name) {
    if (this.isParent) {
      ++this.parentBlocks;
      var blockName = 'block_' + (this.escName(name) || this.parentBlocks);
      this.push('block(typeof ' + blockName + ' == "function" ? ' + blockName + ' : function() {');
    } else if (this.hasParent) {
      this.isSilent = false;
      ++this.childBlocks;
      blockName = 'block_' + (this.escName(name) || this.childBlocks);
      this.push('function ' + blockName + '() {');
    }
    this.nest.unshift('block');
  },
  endblock: function () {
    this.nest.shift();
    if (this.isParent) {
      this.push('});');
    } else if (this.hasParent) {
      this.push('}');
      this.isSilent = true;
    }
  },
  extends: function (name) {
    name = this.parseQuoted(name);
    var parentSrc = this.readTemplateFile(name);
    this.isParent = true;
    this.tokenize(parentSrc);
    this.isParent = false;
    this.hasParent = true;
    //silence output until we enter a child block
    this.isSilent = true;
  },
  include: function (name) {
    name = this.parseQuoted(name);
    var incSrc = this.readTemplateFile(name);
    this.isInclude = true;
    this.tokenize(incSrc);
    this.isInclude = false;
  },
};
//liquid style
tagHandlers.assign = tagHandlers.set;
//python/django style
tagHandlers.elif = tagHandlers.elseif;

var getRuntime = (data, opts) => {
  var defaults = { autoEscape: 'toJson' };
  var _toString = Object.prototype.toString;
  var _hasOwnProperty = Object.prototype.hasOwnProperty;
  var getKeys =
    Object.keys ||
    function (obj) {
      var keys = [];
      for (var n in obj) if (_hasOwnProperty.call(obj, n)) keys.push(n);
      return keys;
    };
  var isArray =
    Array.isArray ||
    function (obj) {
      return _toString.call(obj) === '[object Array]';
    };
  var create =
    Object.create ||
    function (obj) {
      function F() {}

      F.prototype = obj;
      return new F();
    };
  var toString = function (val) {
    if (val == null) return '';
    return typeof val.toString == 'function' ? val.toString() : _toString.call(val);
  };
  var extend = function (dest, src) {
    var keys = getKeys(src);
    for (var i = 0, len = keys.length; i < len; i++) {
      var key = keys[i];
      dest[key] = src[key];
    }
    return dest;
  };
  //get a value, lexically, starting in current context; a.b -> get("a","b")
  var get = function () {
    var val,
      n = arguments[0],
      c = stack.length;
    while (c--) {
      val = stack[c][n];
      if (typeof val != 'undefined') break;
    }
    for (var i = 1, len = arguments.length; i < len; i++) {
      if (val == null) continue;
      n = arguments[i];
      val = _hasOwnProperty.call(val, n) ? val[n] : typeof val._get == 'function' ? (val[n] = val._get(n)) : null;
    }
    return val == null ? '' : val;
  };
  var set = function (n, val) {
    stack[stack.length - 1][n] = val;
  };
  var push = function (ctx) {
    stack.push(ctx || {});
  };
  var pop = function () {
    stack.pop();
  };
  var write = function (str) {
    output.push(str);
  };
  var filter = function (val) {
    for (var i = 1, len = arguments.length; i < len; i++) {
      var arr = arguments[i],
        name = arr[0],
        filter = filters[name];
      if (filter) {
        arr[0] = val;
        //now arr looks like [val, arg1, arg2]
        val = filter.apply(data, arr);
      } else {
        throw new Error('Invalid filter: ' + name);
      }
    }
    if (opts.autoEscape && name !== opts.autoEscape && name !== 'safe') {
      //auto escape if not explicitly safe or already escaped
      val = filters[opts.autoEscape].call(data, val);
    }
    output.push(val);
  };
  var each = function (obj, loopvar, fn1, fn2) {
    if (obj == null) return;
    var arr = isArray(obj) ? obj : getKeys(obj),
      len = arr.length;
    var ctx = { loop: { length: len, first: arr[0], last: arr[len - 1] } };
    push(ctx);
    for (var i = 0; i < len; i++) {
      extend(ctx.loop, { index: i + 1, index0: i });
      fn1((ctx[loopvar] = arr[i]));
    }
    if (len === 0 && fn2) fn2();
    pop();
  };
  var block = function (fn) {
    push();
    fn();
    pop();
  };
  var render = function () {
    return output.join('');
  };
  data = data || {};
  opts = extend(defaults, opts || {});
  var filters = extend(
    {
      html: function (val) {
        return toString(val)
          .split('&')
          .join('&amp;')
          .split('<')
          .join('&lt;')
          .split('>')
          .join('&gt;')
          .split('"')
          .join('&quot;');
      },
      safe: function (val) {
        return val;
      },
      toJson: function (val) {
        if (typeof val === 'object') {
          return JSON.stringify(val);
        }
        return toString(val);
      },
    },
    opts.filters || {},
  );
  var stack = [create(data || {})],
    output = [];
  return {
    get: get,
    set: set,
    push: push,
    pop: pop,
    write: write,
    filter: filter,
    each: each,
    block: block,
    render: render,
  };
};
var runtime;

const jinja = {
  compile: function (markup: string, opts: any = {}) {
    let parser = new Parser();
    parser.readTemplateFile = this.readTemplateFile;
    let code: any[] | string = [];
    code.push('function render($) {');
    code.push(
      'let get = $.get, set = $.set, push = $.push, pop = $.pop, write = $.write, filter = $.filter, each = $.each, block = $.block;',
    );
    code.push.apply(code, parser.parse(markup));
    code.push('return $.render();');
    code.push('}');
    code = code.join('\n');
    if (opts.runtime !== false) {
      runtime = runtime || (runtime = getRuntime);
    }
    const render = new Function('object', 'return (' + code + ')(object)');
    const fn = function (data, options) {
      return render(runtime(data, options));
    };
    return { render: fn };
  },
  render: function (markup: string, data: object, opts: any = null) {
    const tmpl = this.compile(markup);
    return tmpl.render(data, opts);
  },
  templateFiles: [],
  readTemplateFile: function (name: string) {
    const templateFiles = this.templateFiles || [];
    const templateFile = templateFiles[name];
    if (templateFile == null) {
      throw new Error('Template file not found: ' + name);
    }
    return templateFile;
  },
};

export default jinja;
